// Résumés intelligents : générés depuis les sources du cours (RAG), avec citations validées. import { NextResponse } from "next/server"; import { z } from "zod"; import { apiError, parseBody, requireEnrollment } from "@/lib/api.ts"; import { assertSameOrigin, requireUser } from "@/lib/auth/session.ts"; import { all, run } from "@/lib/db/index.ts"; import { normalizeCourse } from "@/lib/learning/helpers.ts"; import { getPrompt } from "@/lib/prompts.ts"; import { completeChat } from "@/lib/openrouter/client.ts"; import { resolvePreset, getModel, estimateCost } from "@/lib/openrouter/registry.ts"; import { buildContext, type RetrievedChunk } from "@/lib/rag/search.ts"; import { resolveCitations } from "@/lib/rag/citations.ts"; import { checkBudget, logActivity, logUsage } from "@/lib/usage.ts"; export async function GET(_req: Request, ctx: { params: Promise<{ course: string }> }) { try { const user = await requireUser(); const course = normalizeCourse((await ctx.params).course); requireEnrollment(user.id, course); const summaries = all( `SELECT id, scope, ref, title, content, citations, created_by, created_at FROM summaries WHERE course_code = ? AND (owner_user_id IS NULL OR owner_user_id = ?) ORDER BY id DESC LIMIT 100`, course, user.id ); return NextResponse.json({ summaries }); } catch (e) { return apiError(e); } } const schema = z.object({ scope: z.enum(["week", "concept", "exam-prep"]), week: z.number().int().min(1).max(14).optional(), conceptSlug: z.string().max(120).optional(), style: z.enum(["ultra-court", "detaille", "avec-formules"]).default("detaille"), }); export async function POST(req: Request, ctx: { params: Promise<{ course: string }> }) { try { await assertSameOrigin(); const user = await requireUser(); const course = normalizeCourse((await ctx.params).course); requireEnrollment(user.id, course); const body = await parseBody(req, schema); const budget = checkBudget(user.id); if (!budget.ok) return NextResponse.json({ error: budget.reason }, { status: 429 }); // Sélection directe des fragments (pas de recherche : on veut la couverture du scope). let chunks: RetrievedChunk[] = []; let title = ""; let ref = ""; if (body.scope === "week") { if (!body.week) return NextResponse.json({ error: "Semaine requise." }, { status: 400 }); chunks = all( `SELECT c.id, c.document_id, c.course_code, c.space, c.ref_type, c.ref_number, c.ref_label, c.section_title, c.title, c.content, c.box_types, c.week, d.title as doc_title, d.path as doc_path, d.filename, 1.0 as score FROM chunks c JOIN documents d ON d.id = c.document_id WHERE c.course_code = ? AND c.space = ? AND c.week = ? AND c.ref_type = 'slide' ORDER BY c.ref_number LIMIT 90`, course, `official-${course.toLowerCase()}`, body.week ); title = `Résumé — Séance ${body.week}`; ref = `semaine-${body.week}`; } else if (body.scope === "concept") { if (!body.conceptSlug) return NextResponse.json({ error: "Concept requis." }, { status: 400 }); const { hybridSearch } = await import("@/lib/rag/search.ts"); const concept = all<{ name: string; description: string }>( "SELECT name, description FROM concepts WHERE course_code = ? AND slug = ?", course, body.conceptSlug )[0]; if (!concept) return NextResponse.json({ error: "Concept inconnu." }, { status: 404 }); chunks = await hybridSearch({ query: concept.name + ". " + concept.description, spaces: [`official-${course.toLowerCase()}`], k: 12 }); title = `Résumé — ${concept.name}`; ref = body.conceptSlug; } else { chunks = all( `SELECT c.id, c.document_id, c.course_code, c.space, c.ref_type, c.ref_number, c.ref_label, c.section_title, c.title, c.content, c.box_types, c.week, d.title as doc_title, d.path as doc_path, d.filename, 1.0 as score FROM chunks c JOIN documents d ON d.id = c.document_id WHERE c.course_code = ? AND c.space = ? AND c.box_types != '' AND c.ref_type = 'slide' ORDER BY c.week, c.ref_number LIMIT 80`, course, `official-${course.toLowerCase()}` ); title = "Résumé de préparation à l'examen"; ref = "exam-prep"; } if (!chunks.length) return NextResponse.json({ error: "Aucun contenu trouvé pour ce choix." }, { status: 404 }); const context = buildContext(chunks, 26_000); const styleInstr = { "ultra-court": "Résumé ULTRA-COURT : une page maximum, uniquement l'essentiel en listes serrées.", detaille: "Résumé DÉTAILLÉ : structuré par sous-thèmes, avec exemples brefs et erreurs fréquentes.", "avec-formules": "Résumé AXÉ FORMULES : chaque formule en LaTeX avec ses variables définies, ses conditions d'application et un mini-exemple chiffré.", }[body.style]; const model = await resolvePreset("recommande"); const t0 = Date.now(); const { text, promptTokens, completionTokens } = await completeChat({ model, messages: [ { role: "system", content: [getPrompt("base-system"), course === "IMM1003" ? getPrompt("course-imm1003") : getPrompt("course-imm1033"), getPrompt("rag-grounding"), getPrompt("citation-policy")].join("\n\n---\n\n"), }, { role: "user", content: `Produis « ${title} » pour le cours ${course}. ${styleInstr}\nTermine par une section « Questions d'auto-vérification » (3 questions sans réponse).\n\nMatériel du cours :\n${context.text}`, }, ], maxTokens: 4000, temperature: 0.3, }); const mi = await getModel(model); logUsage({ userId: user.id, model, kind: "summary", tokensIn: promptTokens, tokensOut: completionTokens, cost: mi ? estimateCost(mi, promptTokens, completionTokens) : 0, latencyMs: Date.now() - t0 }); const { cleaned, citations } = resolveCitations(text, context); const r = run( `INSERT INTO summaries (course_code, scope, ref, title, content, citations, owner_user_id, created_by) VALUES (?, ?, ?, ?, ?, ?, ?, 'ai')`, course, body.scope, ref, title, cleaned, JSON.stringify(citations), user.id ); logActivity(user.id, "summary", course, 120); return NextResponse.json({ ok: true, id: Number(r.lastInsertRowid), title, content: cleaned, citations }); } catch (e) { return apiError(e); } }